spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t } from '@/i18n';5import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';6import { apiAnalytics } from '@/lib/api-analytics';7import { apiExplore } from '@/lib/api-explore';8import { regionShort } from '@/lib/regions';9import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';10import { routes } from '@/lib/site';11import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';12import type { CountryResponse, FormatSpec } from '@/lib/types';13import { CountryHeader } from '@/components/country/country-header';14import { CountryStory } from '@/components/country/country-story';15import { DnaPanel } from '@/components/country/dna-panel';16import { KeyFacts } from '@/components/country/key-facts';17import { SimilarPanel } from '@/components/country/similar-panel';18import { Timeline } from '@/components/country/timeline';19import { CountryTopicsGrid } from '@/components/country/topics-grid';20import { ChangeList } from '@/components/data/change-list';21import { NotBuiltState } from '@/components/data/empty-state';22import { Metric, MetricGrid } from '@/components/data/metric';23import { Section } from '@/components/data/section';24import { TopicNav } from '@/components/data/topic-nav';2526export const revalidate = 900;2728type Params = { slug: string };2930async function loadCountry(slug: string): Promise<CountryResponse | 'not-built' | null> {31 try {32 return await api.country(slug);33 } catch (e) {34 if (isNotFound(e)) return null;35 if (isNotBuilt(e)) return 'not-built';36 throw e;37 }38}3940export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {41 const { slug } = await params;42 const data = await loadCountry(slug);43 if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } };44 const name = data.country.name ?? slug;45 const title = seoTitle.country(name);46 const description = t('country.description', { name });47 const canonical = routes.country(data.country.slug ?? slug);48 return {49 title: { absolute: `${title} | ${t('site.name')}` },50 description,51 alternates: { canonical },52 openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },53 twitter: { card: 'summary_large_image', title, description },54 };55}5657export default async function CountryPage({ params }: { params: Promise<Params> }) {58 const { slug } = await params;59 const data = await loadCountry(slug);60 if (data === null) notFound();61 if (data === 'not-built') return <NotBuiltState />;6263 const c = data.country;64 const id = c.id;65 const name = c.name ?? id;66 const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag };67 // Optional panels in parallel; each tolerates failure independently.68 const [changes, similar, insights, dna, events, story, quality, indicators] = await Promise.all([69 safe(api.countryChanges(id, 8)),70 safe(api.countrySimilar(id, 'overall', 8)),71 safe(api.countryInsights(id)),72 safe(api.countryDna(id)),73 safe(api.countryEvents(id, 120)),74 safe(apiAnalytics.countryStory(id)),75 safe(apiAnalytics.countryQuality(id)),76 safe(apiExplore.indicators({ with_data: true })),77 ]);78 const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data]));79 const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0);80 const formats: Record<string, FormatSpec> = Object.fromEntries((indicators?.items ?? []).map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }]));81 const ld = [jsonLd.country({ slug: c.slug ?? slug, name, iso3: c.iso3 ?? id, capital: c.capital }), jsonLd.breadcrumbs([{ name: t('nav.countries'), path: routes.countries() }, { name, path: routes.country(c.slug ?? slug) }])];8283 return (84 <>85 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />86 <CountryHeader data={data} quality={quality} />87 <TopicNav slug={c.slug ?? slug} counts={counts} />8889 <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0">90 <MetricGrid>91 {data.headline.map((m) => {92 const topic = HEADLINE_TOPIC[m.indicator as (typeof HEADLINE_INDICATORS)[number]];93 return <Metric key={m.indicator} metric={m} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} href={topic ? routes.countryIndicator(c.slug ?? slug, topic, m.indicator) : null} />;94 })}95 </MetricGrid>96 </Section>9798 {story && story.items.length ? (99 <Section id="story" title={t('country.story.title', { name })} subtitle={t('country.story.sub', { since: story.since ?? '', n: story.items.length })}>100 <CountryStory data={story} country={countryRef} />101 </Section>102 ) : null}103104 <div className="grid gap-x-10 lg:grid-cols-2">105 <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')} actions={<Link href={`${routes.changes()}?country=${c.slug ?? id}`} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}>106 <ChangeList items={changes?.items ?? []} />107 </Section>108 <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}>109 <SimilarPanel countryId={id} countrySlug={c.slug} countryName={name} initial={similar} formats={formats} />110 </Section>111 </div>112113 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">114 <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}>115 <DnaPanel countryId={id} name={name} initial={dna} />116 </Section>117 <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}>118 <KeyFacts items={insights?.items ?? []} country={countryRef} />119 {c.languages?.length ? (120 <p className="mt-4 text-sm text-ink-2">121 <span className="text-ink-3">{t('country.languages')}: </span>122 {c.languages.join(', ')}123 </p>124 ) : null}125 {data.groups.length ? (126 <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-ink-2">127 <span className="text-ink-3">{t('country.memberOf')}: </span>128 {data.groups129 .filter((g) => g.kind === 'org')130 .map((g) => (131 <Link key={g.id} href={routes.region(g.slug ?? g.id)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-ink hover:text-accent">132 {g.name}133 </Link>134 ))}135 </p>136 ) : null}137 </Section>138 </div>139140 <Section id="timeline" title={t('country.timeline.title')} subtitle={t('country.timeline.sub')}>141 <Timeline items={events?.items ?? []} slug={c.slug ?? slug} />142 </Section>143144 <Section id="topics" title={t('country.topics.title', { name })} subtitle={t('country.topics.sub', { n: data.topics.length, m: withData })}>145 <CountryTopicsGrid slug={c.slug ?? slug} topics={data.topics} />146 </Section>147 </>148 );149}150